Skip to content

Make the keybindings menu's Lua bind scan safe against load-time config reads - #7564

Open
tobi wants to merge 8 commits into
omacom:quattrofrom
tobi:fix/menu-keybindings-stub-scan
Open

tobi wants to merge 8 commits into
omacom:quattrofrom
tobi:fix/menu-keybindings-stub-scan

Conversation

@tobi

@tobi tobi commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Problem

omarchy-menu-keybindings learns about Lua-only binds by dofile-ing the user's hyprland.lua under a stub hl whose every lookup returns a truthy noop table. Real configs read compositor state at load time, and the stub cannot survive that:

  1. Runaway lua at 100 % CPU. for _, m in ipairs(hl.get_monitors()) never terminates, because noop[1], noop[2], … are all non-nil. The menu exits, but the lua child keeps spinning under systemd --user until reboot. Found one on my laptop that had run for 27 hours after a single SUPER+K (PIDs omarchy-menu-keybindings → lua, 60 % of a core, dofile(hyprland.lua) never returned).

    local noop = setmetatable({}, { __index = function() return noop end, __call = function() return noop end })
    for _, m in ipairs(noop) do end   -- never returns
  2. Silently dropped binds. On current quattro, default/hypr/qconsole.lua:64 does monitor.scale <= 0, which under the stub raises attempt to compare table with number. The scan is pcall'd, so it just stops there — every Lua bind declared after that require is missing from the menu (DEBUG=1 shows lua bind scan failed: …qconsole.lua:64).

Fix

  • noop.__index answers nil for numeric keys (so ipairs/numeric loops end), __len is 0, and __lt/__le, arithmetic, __concat, __tostring degrade to harmless values instead of raising. String-key lookups behave as before.
  • The scan is wrapped in timeout 10 so a config the stub still cannot satisfy can never outlive the menu.

Test

test/shell.d/menu-keybindings-lua-scan-test.sh extracts the embedded scan and runs it against a fixture hyprland.lua that iterates monitors, compares monitor.scale, does arithmetic/concatenation on stub fields, and then declares two binds. Without the fix it hangs for the full 10 s timeout and fails; with it the scan returns in milliseconds and both binds are reported. Also verified on my own config: the scan now completes with no scan failed and lists the Lua binds that were previously lost after qconsole.lua.

Fixes #7025

Fixes #12052

Fixes #12124

…ig reads

omarchy-menu-keybindings learns about Lua-only binds by running the user's
hyprland.lua under a stub `hl` whose every lookup answers a truthy noop
table. That stub breaks as soon as a config reads compositor state at load
time, which real configs do:

- `for _, m in ipairs(hl.get_monitors())` never terminates, because noop[1],
  noop[2], ... are all non-nil. The menu exits, the `lua` child keeps
  spinning at 100% CPU under systemd --user until the next reboot. One
  such orphan ran for 27 hours on a laptop after a single SUPER+K.
- `monitor.scale <= 0` (default/hypr/qconsole.lua:64) raises "attempt to
  compare table with number", which aborts the pcall'd scan, so every bind
  declared after that file is silently missing from the menu.

The stub now answers nil for numeric indexes (so ipairs/`#` terminate),
reports length 0, and degrades comparisons, arithmetic, and concatenation
to harmless values instead of raising. The scan is also wrapped in
`timeout 10` so a config the stub still cannot satisfy can never outlive
the menu.

Adds a shell test that scans a fixture config doing exactly those
load-time reads; without the fix it hangs for the full timeout.
Copilot AI balanced review requested due to automatic review settings August 20, 2026 01:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Hardens Lua keybinding discovery against hangs and load-time type errors.

Changes:

  • Time-boxes Lua scanning to 10 seconds.
  • Adds safer proxy metamethods.
  • Adds regression coverage for loops, comparisons, and arithmetic.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 1 out of 2 changed files in this pull request and generated no comments.

File Description
bin/omarchy-menu-keybindings Hardens and time-boxes Lua bind scanning.
test/shell.d/menu-keybindings-lua-scan-test.sh Tests load-time reads and timeout configuration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@agush22

agush22 commented Aug 30, 2026

Copy link
Copy Markdown

Ran into this exact issue after upgrading to Quattro 4.0
Pressing SUPER + K pegged a background lua process at 100% CPU because my monitors.lua iterates over hl.get_monitors() with ipairs.

Tested this fix locally; it resolves the infinite loop cleanly and makes the keybindings scan instantaneous. Hope to see this merged soon!

@shwig1

shwig1 commented Sep 14, 2026

Copy link
Copy Markdown

Confirming on omarchy 4.0.3-1 (Hyprland 0.56.2, Lua 5.5.1): a personal module that
iterated hl.get_windows() at require time hung the scan, and repeated Super+K presses
left eight orphaned lua processes at 100% CPU each for over six hours. The timeout in
this PR is the part that would have contained that; the stub fix removes the trigger.

@alinuxfan

Copy link
Copy Markdown

Confirming another real-world manifestation of this on 4.0.0.r2147.g2fbac0c-1 — this one doesn't just drop a bind from the menu, it makes the menu launch the wrong app.

I have flea set as my default file manager. Its installer (flea --default) correctly unbinds and rewrites SUPER+SHIFT+F in ~/.config/hypr/bindings.lua:

o.bind("SUPER + SHIFT + F", "File manager", { launch = 'flea --gui' })

Pressing SUPER+SHIFT+F directly opens flea correctly (hyprctl binds shows the live dispatcher resolving as expected). But selecting the same "File manager" row from the SUPER+K search menu opens Nautilus instead.

Root cause is this PR's diagnosis exactly: hyprland.lua loads default.hypr.omarchy (which pulls in default/hypr/qconsole.lua) before hypr.bindings (the user's own overrides). The scan crashes inside qconsole.lua — same attempt to compare number with table on a monitor.scale > 0 check, now at line 123 rather than 64, so the line's moved but the bug hasn't:

$ DEBUG=1 omarchy-menu-keybindings
[DEBUG] lua bind scan failed: /usr/share/omarchy/default/hypr/qconsole.lua:123: attempt to compare number with table

Because the crash happens before hypr.bindings is ever reached, the scan's cache keeps the stale default from default/hypr/bindings/applications.lua:

$ lua <extracted scan snippet>
65      File manager    F       exec    omarchy-launch-nautilus
73      File manager (cwd)      F       exec    omarchy-launch-nautilus-cwd

...instead of the user's actual flea --gui override. So it's not just that entries silently go missing — for anyone who has overridden a default Omarchy keybind, the search menu can confidently dispatch the old action while the real key combo does the right thing. Would be good to land this fix; the noop stub degrading harmlessly under comparison (not just under ipairs/length) directly fixes this case too.

omarchybot and others added 2 commits September 28, 2026 12:37
The cache key hashes only the version, the active keymap and `hyprctl binds`, so a records file written by the scan that aborted in qconsole.lua keeps being served after this fix lands, and the menu goes on dispatching the stale default in place of the user's override until their binds happen to change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
@omarchybot omarchybot added verified Omarchy Triage has verified that this issue is ready for final review and removed verified Omarchy Triage has verified that this issue is ready for final review labels Sep 28, 2026
@omarchybot

Copy link
Copy Markdown
Collaborator

Reviewed and reproduced on a worker built from the Omarchy ISO, with the stock config plus a user o.rebind("SUPER + SHIFT + F", "File manager", { launch = "flea" }) and, separately, an ipairs(hl.get_monitors()) loop:

  • On quattro the scan aborts at default/hypr/qconsole.lua:123 and the menu's File manager row dispatches omarchy-launch-nautilus; the loop hangs the menu until killed.
  • On this branch the row dispatches flea and the loop returns at once. With the stub changes reverted, both failures come back. A config stuck in while true do end now ends at the 10 s timeout and leaves no lua behind.
  • test/shell.d/menu-keybindings-lua-scan-test.sh passes 3/3 here and fails on quattro; test/shell.d/keybindings-menu-test.sh passes 16/16. ./test/cli stops at "vscode generated theme references current theme file" on this branch and on quattro alike, so that failure is not this change's, but it does mean the rest of that suite did not run.

I pushed two commits to the branch. The first merges quattro, which had moved the cache version to v14. The second bumps the version to v15. The records cache is keyed only on that version, the keymap and hyprctl binds, so without the bump anyone already holding a cache from the broken scan keeps getting the stale default after this lands. I confirmed that on the worker: a v14 cache written by quattro kept dispatching nautilus until the bump.

Six other open pull requests fix parts of the same bug: #8876, #12712, #12916, #13276, #12155 and #11733. This one fixes both the endless loop and the qconsole abort, and it has a test that fails without the fix. #12712, #12916 and #12155 do not get past qconsole. #8876 gets past it by returning nil monitors, which still breaks a config that reads a field off one. #13276 has no test. #11733 is a redesign that would need its own review.

Second opinion from Codex Medium, which agreed with that choice and with the cache bump (its independence from this review is not guaranteed). It also raised two design points that I have not changed, because they are a call about how far the stub should pretend:

  • A scan cut off by the timeout is cached like a finished one, so its partial records stay until the binds change. A scan that errors partway is already cached the same way.
  • Because comparisons, arithmetic and concatenation on stub values now return made-up results, a config that guards a comparison with pcall, or builds a command from a monitor field, can take a different branch or record a truncated command where the scan used to stop.

This links #7025, #12052 and #12124, which it fixes. The next step is the maintainer's.

@linyiru

linyiru commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Merged this with #12557 locally and measured it, because the two edit the same scan and are textually compatible while being behaviourally incompatible. git merge-tree reports no conflict, and the merged tree fails one test from each side.

#12557 adds an id column to the scan's output, inserted as field 3, so a row that read modmask description key kind arg reads modmask description id key kind arg. That breaks the two fixed-string assertions in menu-keybindings-lua-scan-test.sh:52-53, which pin the five-field layout:

expected: 64	Keybindings menu	K	exec	omarchy-menu keybindings
actual:   64	Keybindings menu	Keybindings menu	K	exec	omarchy-menu keybindings

In the other direction, this PR rewrites the heredoc to timeout 10 lua <<'LUA', and #12557's hyprland-bind-id-test.sh extracts the scan by matching that line exactly, so on the merge the extraction returns nothing and the five assertions that read scan output go with it.

On the merge: menu-keybindings-lua-scan-test.sh 1 ok and 1 not ok, hyprland-bind-id-test.sh 4 ok and 1 not ok, keybindings-menu-test.sh unaffected at 16/16. Both breakages are a one-line fix in whichever PR lands second, and I will make them on my side if this one lands first. Writing it down because the repo has no CI, so nothing would surface it before a merge.

Separately, as the author of one of the seven PRs touching this scan: this is the only one of them whose test fails on quattro and passes on the branch, which is the property that makes a fix here checkable later. The timeout matters for a reason the other candidates cannot cover either, since a stub that degrades comparisons can still be sent into a loop by a config that iterates on a condition.

linyiru added a commit to linyiru/omarchy that referenced this pull request Sep 30, 2026
Pairing and sorting happen before a record reaches the cache, and the cache key
hashes only the version, the active keymap and `hyprctl binds`. So on a machine
whose keymap and binds have not moved, the menu goes on serving the rows the
previous version laid out and none of the sorting above reaches the user until
something unrelated happens to change their binds.

The version skips v15 on purpose: omacom#7564 and omacom#8229 are both open and both take it,
and a merge that agrees on the same literal from both sides resolves silently
into no bump at all.
buger added a commit to probelabs/omarchy that referenced this pull request Oct 5, 2026
…shipped trigger and a user-module trigger as separate arms

The KI is "one throwing module ends the scan and every later bind is lost";
the reproducer only covered the shipped trigger (qconsole's scale
comparison). Arm A keeps that case. Arm B guards the qconsole comparisons
and adds a user monitors.lua that formats a mode string with
string.format("%dx%d@%d", ...) from the active monitor: the library call
rejects the mock value, the scan ends before hypr.bindings, and the SSH bind
is lost. Each arm prints its own verdict; exit 0 while either arm shows the
defect. The cache maps are cleared before each scan (they are global), so
an arm cannot read binds cached by the previous one.

Measured on Linux (Lua 5.5.1): quattro-proof 1e43373 arm A and B present
(exit 0); omacom#7564 merged, arm A fixed and arm B present
(exit 0); omacom#7564 plus a per-module pcall around require, both fixed
(exit 1). The control arm caches the bind on all three.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
buger added a commit to probelabs/omarchy that referenced this pull request Oct 5, 2026
…shipped trigger and a user-module trigger as separate arms

The KI is "one throwing module ends the scan and every later bind is lost";
the reproducer only covered the shipped trigger (qconsole's scale
comparison). Arm A keeps that case. Arm B guards the qconsole comparisons
and adds a user monitors.lua that formats a mode string with
string.format("%dx%d@%d", ...) from the active monitor: the library call
rejects the mock value, the scan ends before hypr.bindings, and the SSH bind
is lost. Each arm prints its own verdict; exit 0 while either arm shows the
defect. The cache maps are cleared before each scan (they are global), so
an arm cannot read binds cached by the previous one.

Measured on Linux (Lua 5.5.1): quattro-proof 1e43373 arm A and B present
(exit 0); omacom#7564 merged, arm A fixed and arm B present
(exit 0); omacom#7564 plus a per-module pcall around require, both fixed
(exit 1). The control arm caches the bind on all three.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
buger added a commit to probelabs/omarchy that referenced this pull request Oct 5, 2026
…e on this mirror (box, linux)

Arm A (shipped config) no longer reproduces with omacom#7564; arm B (a user module that hands a stub value to string.format) still does, so the green tripwire passes and the KI is still reproduced here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
buger added a commit to probelabs/omarchy that referenced this pull request Oct 6, 2026
…ro-proof 1e43373)

Mirror of omacom#7564 at head 7b49d0b: the upstream change applied onto
quattro-proof 1e43373, followed by the proof work for the review. This
commit changes no file; it records the mirror at the branch tip, where the
review pipeline reads it (Proof-* trailers).

Proof-Change-Key: pr/7564
Proof-Upstream-Repo: github.com/omacom/omarchy
Proof-Mirror-Of: refs/pull/7564/head
Proof-Upstream-Head: 7b49d0b
Proof-Head-Ref: fix/menu-keybindings-stub-scan
Proof-Change-Base: de63134
Proof-Base-Ref: quattro
Proof-Baseline: 1e43373
Proof-Baseline-Ref: quattro-proof
@buger

buger commented Oct 8, 2026

Copy link
Copy Markdown

TL;DR: This fixes the reported scan failure. One optional follow-up for custom configs: a type error in monitors.lua can still prevent bindings.lua from loading. Catching each require separately preserves later binds; the two tests below cover a numeric format and direct monitor indexing. This need not hold up the shipped-config fix.

Remaining aborts and proposed tests, with the shipped-config baseline

I ran the real build_lua_bind_cache from each tree against the shipped config/hypr, plus two user binds in bindings.lua, on Lua 5.5.1 (Linux). This head merges onto 035ce29f cleanly, and its scan code is byte-identical either way.

quattro 035ce29f this PR (7b49d0bb) on 035ce29f
Scan of the shipped config stops at default/hypr/qconsole.lua:123 (line 64 in the description; it has moved) completes
User o.rebind("SUPER + RETURN", "Terminal", { launch = "alacritty" }) the menu row runs the default omarchy-launch-terminal (the wrong-app symptom @alinuxfan reported) uwsm-app -- alacritty
User o.bind("SUPER + SHIFT + R", "SSH", …) missing from the menu cache uwsm-app -- alacritty -e ssh your-server
the PR's menu-keybindings-lua-scan-test.sh run against each tree's scan fails (hangs until its 10s limit) 3/3; keybindings-menu-test.sh still passes

This is our known issue KI-MENU-KEYBINDINGS-SCAN-DOFILE-ABORT. Its reproducer has two arms. The shipped-config arm flips with this head: the scan no longer fails, and the SSH bind is cached. The second arm, a user monitors.lua that builds a mode with string.format, still loses the bind on this head. Both arms pass with the change suggested below.

1. Other reads can still abort the scan. The new stub absorbs operators (comparison, arithmetic, .., #). Some library functions, including numeric string.format conversions and math.floor, reject the stub table instead of using its arithmetic metamethods. math.max uses comparison and can invoke comparison metamethods (Lua 5.5 source). The examples below also include a failed numeric conversion, a numeric for limit and a nil get_config() result. hyprland.lua requires hypr.monitors and hypr.input before hypr.bindings. So any one of these lines in a user's monitors.lua still ends the scan on this head. Every bind from bindings.lua is then missing, and every rebind falls back to its default:

local m = hl.get_active_monitor()
string.format("%dx%d@%d", m.width, m.height, m.refresh_rate)   -- bad argument #2 to 'format'
math.floor(m.width)                                            -- bad argument #1 to 'floor'
if tonumber(m.scale) > 1 then end                              -- tonumber(stub) is nil
string.lower(m.name)                                           -- bad argument #1 to 'lower'
for x = 0, m.width, 960 do end                                 -- 'for' limit must be a number
local gaps = hl.get_config("general.gaps_out") * 2             -- the stub's get_config returns nil

On this head the scan survives ipairs/pairs/# loops, comparisons, arithmetic, .., m.name:match(...), math.max, table.sort and hl.get_config(...) or 1. The monitor/window-loop and wrong-app reports covered by the worker run are fixed by this PR. I did not find these additional examples in this PR's thread, and the shipped monitors.lua sets a literal mode ("preferred"), so this only matters for hand-written monitor logic.

2. A direct [1] now ends the scan. The review of #13276 already identified direct [1] access returning nil with this stub design. I reproduced it here and checked that per-module recovery preserves later binds. In the stock load order, the existing qconsole error masks it; custom code loaded before qconsole can regress. The stub answers nil for a numeric key, which is what lets ipairs end. A line that takes the first element directly then indexes nil:

local first = hl.get_monitors()[1]
hl.monitor({ output = first.name })   -- attempt to index a nil value (local 'first')

hl.get_workspaces()[1].id fails the same way. The old stub answered every key with itself, so with qconsole's comparison guarded, quattro's scan keeps the user's SSH bind and Terminal rebind with this line; this head loses both. if first then … end is unaffected.

Suggestion: in the scan, load each module under its own pcall. A module that still can't run against the stub then costs only its own binds:

-- Load each module under its own pcall, so a module that still cannot run
-- against the stub costs only its own binds, not every bind loaded after it
-- (the user's hypr.bindings is required after hypr.monitors and hypr.input).
local load_module = require
require = function(name)
  local ok, result = pcall(load_module, name)
  if ok then
    return result
  end

  if os.getenv("DEBUG") == "1" then
    io.stderr:write("[DEBUG] lua bind scan skipped " .. tostring(name) .. ": " .. tostring(result) .. "\n")
  end

  return noop
end

It goes just before local config = …. With it, all six lines above and both [1] lines keep the user's binds. So do an error() in a user module and a require of a module that doesn't exist. On the shipped config, the records are byte-identical to this head's, and nothing is logged. It has two limits. A throw inside bindings.lua itself still loses the binds after that line in that file. A loop that never ends still needs your 10s limit: a while w and w.parent do w = w.parent end walk runs to the limit on this head with or without the change. The stub fixes keep their value alongside it, because they keep the binds that come after a stub read inside the same module.

Tests for menu-keybindings-lua-scan-test.sh: they fail on this head and pass with the change
# The user's hypr.bindings loads after hypr.monitors and hypr.input, so a
# module the stub still cannot run must cost only its own binds, not every
# bind loaded after it.
mkdir -p "$TMPDIR/later/.config/hypr"
cat >"$TMPDIR/later/.config/hypr/hyprland.lua" <<'LUA'
package.path = os.getenv("HOME") .. "/.config/?.lua;" .. package.path
require("hypr.monitors")
require("hypr.bindings")
LUA
cat >"$TMPDIR/later/.config/hypr/monitors.lua" <<'LUA'
local monitor = hl.get_active_monitor()
hl.monitor({ output = monitor.name, mode = string.format("%dx%d", monitor.width, monitor.height) })
LUA
cat >"$TMPDIR/later/.config/hypr/bindings.lua" <<'LUA'
hl.bind("SUPER + RETURN", hl.dsp.exec_cmd("alacritty"), { description = "Terminal" })
LUA

output=$(HOME="$TMPDIR/later" timeout 10 lua - <<<"$scan" 2>&1)
grep -qF $'64\tTerminal\tRETURN\texec\talacritty' <<<"$output" ||
  fail "a module the stub cannot run does not drop the binds loaded after it (got: $output)"
pass "a module the stub cannot run does not drop the binds loaded after it"

# A stub list answers nil for [1] so ipairs ends; taking the first element
# directly must not cost the binds loaded later either.
cat >"$TMPDIR/later/.config/hypr/monitors.lua" <<'LUA'
local first = hl.get_monitors()[1]
hl.monitor({ output = first.name })
LUA

output=$(HOME="$TMPDIR/later" timeout 10 lua - <<<"$scan" 2>&1)
grep -qF $'64\tTerminal\tRETURN\texec\talacritty' <<<"$output" ||
  fail "indexing the first element of a stub list does not drop the binds loaded after it (got: $output)"
pass "indexing the first element of a stub list does not drop the binds loaded after it"
Tree Result
this head exit 1: the first three checks pass, then not ok - a module the stub cannot run does not drop the binds loaded after it; run alone, the [1] check fails the same way
this head + the change exit 0, 5/5 ok
The time limit and #13276

Thanks @tobi for the fix and the clear write-up. The worker run by @omarchybot and @linyiru's #12557 note here, and on #13276 @llstrk's review and @KewkLW's merge-order note, already cover the rest, so I haven't repeated them.


Independent verification: the evidence for this PR comes from a full proof audit of 7b49d0b merged onto quattro at 035ce29 (evaluated merge e1ff55c): test report 69 passed · 0 failed · 0 skipped. Rechecked on quattro b83d3df (2026-10-08): the scan script is unchanged but the shipped Hyprland Lua configuration changed, and rerunning this head merged onto that tip preserves the first table's results and the remaining custom-monitor aborts. Checked by running the scan from each tree; not a formal proof.

@aholbreich

Copy link
Copy Markdown

This now conflicts with quattro: e1b0e5e (#14431) changed the scan's line to lua -E - <<'LUA'. I rebased it locally onto quattro (4c1b4cb), keeping both changes: timeout 10 lua -E - <<'LUA'. Two lines of menu-keybindings-lua-scan-test.sh then need to follow, because they match the old line literally:

-scan=$(awk '/lua <<.LUA.$/ { capture = 1; next } /^LUA$/ { capture = 0 } capture' "$script")
+scan=$(awk '/lua (-E - )?<<.LUA.$/ { capture = 1; next } /^LUA$/ { capture = 0 } capture' "$script")
...
-grep -q "timeout 10 lua <<'LUA'" "$script" || fail "Lua bind scan is time-boxed"
+grep -q "timeout 10 lua -E - <<'LUA'" "$script" || fail "Lua bind scan is time-boxed"

With that, menu-keybindings-lua-scan-test.sh passes 3/3, keybindings-menu-test.sh and ./test/cli pass. In the sandbox matrix from earlier, the rebased tree also handles the new report #15216 (a toggle walking hl.get_windows() at load, which hangs v4.0.4) and a floating workspace-layout default (workspace-modes.lua walks windows at load). In all four scenarios it returns in ~50 ms with user binds dispatchable.

@aholbreich

Copy link
Copy Markdown

Rebased again onto current quattro (84cfb93). #15326 added a second conflict in bin/omarchy-menu-keybindings, in the cache key. Keeping quattro's side there (v16 plus the language lines) and timeout 10 lua -E - for the scan, plus the two test lines from my last comment, menu-keybindings-lua-scan-test.sh passes 3/3. keybindings-menu-test.sh gives the same result as on quattro.

One more case this fixes: #14482 and #15263 added default/hypr/workspace-modes.lua, which runs at config load (through default.hypr.toggles) and walks ipairs(hl.get_windows()) once a workspace is saved as floating with Super + L. I scanned a config that loads only the toggles, with one saved floating workspace. On quattro the scan never finishes (killed at an 8 s limit); on the rebased branch it completes and reports the binds declared after it. With the shipped config, quattro still aborts earlier in qconsole.lua, so this hang is hidden today. But a fix that gets past qconsole without ending ipairs would expose it to everyone using floating workspaces.

quattro now runs the scan as `lua -E -` and keys the cache at v17. Keep both: the scan runs as `timeout 10 lua -E -`, the scan test extracts and checks that line, and the cache moves to v18 so caches written by the broken scan on quattro are rescanned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@omarchybot omarchybot added the verified Omarchy Triage has verified that this issue is ready for final review label Oct 15, 2026
@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable issues remain.

Summary

Makes the keybindings scan survive load-time reads of Hyprland state.

  • The keybindings menu can scan Lua binds without getting stuck.

The earlier Lua-environment concern is addressed: both standalone scan runs now use lua -E -. No new actionable issues were found.

Reviews (3) · Last reviewed commit: "Check the bind scan finishes without a w..." · Reviewed by Greptile

Comment thread test/shell.d/menu-keybindings-lua-scan-test.sh Outdated
@omarchybot

Copy link
Copy Markdown
Collaborator

This conflicted with quattro, so I merged quattro (246f33a) into the branch and pushed it as 25a1cbe. The scan now runs as timeout 10 lua -E -, which keeps the -E from #14431. The cache version moves to v18 because quattro is already at v17, so caches written by the broken scan are still rescanned. menu-keybindings-lua-scan-test.sh now extracts and checks the new line, as @aholbreich described above.

I checked the new head on a worker built from the Omarchy ISO, using its stock config:

  • With a user o.rebind("SUPER + SHIFT + F", "File manager", { launch = "flea" }), quattro's scan aborts at default/hypr/qconsole.lua:123, so the menu's File manager row runs omarchy-launch-nautilus. On this branch the row runs flea. With the stub changes reverted, it runs nautilus again.
  • An ipairs(hl.get_monitors()) loop before the defaults load hangs quattro until it is killed. On this branch the menu returns in 0.4 s.
  • A config stuck in while true do end stops at the 10 s limit. Its result is cached, and no lua is left running.
  • menu-keybindings-lua-scan-test.sh passes 3/3 here, and fails on quattro and with the stub reverted. keybindings-menu-test.sh passes 20/20 and menu-keybindings-key-names-test.sh 3/3. ./test/cli passes, and CI is green.

#14128 does not cache a scan that its loop guard stopped. I considered bringing that here for the timeout and left it out. With it, a config that hangs under the stub costs about 20 s on every SUPER+K (measured on the worker): the result is never cached, and a failed cache write makes the menu scan a second time. Such a hang happens the same way on every scan, so scanning again finds nothing new. Without it, the hang costs 10 s once and the partial result is cached, which is how a scan that errors part way is already treated.

Other open fixes for this bug: #12712 ends the ipairs loop but still aborts at the qconsole.lua comparison, so a user's rebinds still run the default action. #13276 makes nearly the same stub change, with no time limit and no test. #14128 is the change above. This one is the oldest, and the only one that fixes both the hang and the abort. #13276 also handles bitwise operators on stub values, which this does not; no report has hit that case.

I had Codex Medium give a second opinion. It found no defects in the merge. On the timeout question it reached the same conclusion, but my brief had already pointed at the second scan, so that agreement is not independent. Comparing the four diffs, it also picked this one.

This waits on the maintainer: #12712 already carries ready, so this one is labelled verified and not ready. Which of the two lands is the maintainer's call.

omarchybot and others added 3 commits October 17, 2026 10:12
A config the stub cannot end held the menu for the full 10 s before the timeout killed the scan. An instruction-count hook now raises inside the pcall instead, after 50M instructions (about 0.3 s on a worker; the stock config uses about 69k), and the scan reports the binds it found, as it already does for one that errors part way. That result is cached like theirs: a config that runs away under the stub will almost always do so again, and rescanning would only repeat the wait. The timeout stays as the backstop for what the hook cannot reach: a loop in a coroutine, or one that catches the error and keeps going.

Co-Authored-By: AnPod <andrej@podgorsek.de>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tact

Runs the whole menu under a timeout with a plugin-style ipairs(hl.get_windows()) loop ahead of the stock config, and checks the Lua-only Terminal bind still resolves, which it can only do if the scan got past the loop to that bind.

Co-Authored-By: nekomario28 <206304251+nekomario28@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Otherwise a LUA_INIT in the tester's environment runs ahead of the fixture, and a LUA_PATH changes what a config's require finds, either of which can fail a test the menu itself would pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@omarchybot

Copy link
Copy Markdown
Collaborator

I pushed three commits to this branch (now b1ac64e). They bring in the best parts of two later fixes for the same hang, with their authors credited as co-authors:

  • From Fix Super+K hang when config iterates hl.get_workspaces() #14128 (@AnPod): the scan now runs under an instruction-count hook, so a config that loops in a way the stub cannot end stops after 50M instructions instead of holding the menu for the full 10 s. On a worker, a while true do end config returns in 0.29 s instead of 10.0 s, and the bind declared before the loop is still reported. The stock config needs about 69k instructions, and it scans to the same 234 binds with or without the hook. timeout 10 stays as the backstop for what the hook cannot reach: a loop inside a coroutine, or a config that catches the error and keeps going. A scan the hook stops is cached like one that errors part way. That is the one part of Fix Super+K hang when config iterates hl.get_workspaces() #14128 I left out, for the reason in my last comment: the same config would just stop again on the next SUPER+K.
  • From End the bind scan's stub window list instead of looping on it #15308 (@nekomario28): an end-to-end test in keybindings-menu-test.sh. It puts an ipairs(hl.get_windows()) walk ahead of the stock config, runs the whole menu under a timeout and checks that the Lua-only Terminal bind still resolves.
  • menu-keybindings-lua-scan-test.sh now runs the scan with lua -E, as the menu does, which Greptile pointed out. Without it, a LUA_INIT or LUA_PATH in the tester's environment could fail the test. A new test there checks that the runaway config above stops early and keeps its binds.

What ran, on a worker built from the Omarchy ISO: the scan test and the window-walk test both fail on quattro (cce8b1c) and with the stub change reverted, and the runaway test fails without the hook. On this head, menu-keybindings-lua-scan-test.sh passes 4/4, keybindings-menu-test.sh 21/21, menu-keybindings-key-names-test.sh 3/3, and ./test/cli passes.

Codex Medium reviewed the three commits over two rounds. It found no defects in the code. In the first round it pointed out wording in the commits that claimed more than was shown: a general speed claim, and a test described as proving the whole scan finished. It also raised the coroutine case. All of that is corrected in what I pushed, and its second round was clean. It can read this session's notes, so where it agrees with me, that agreement isn't guaranteed to be independent.

This is labelled verified at the new head. It still waits on the maintainer, because #12712 carries ready for the same bug and only one of the two should land.

The scan tests failed if a scan took five seconds or more, which a slow or loaded machine can hit with nothing wrong. A scan that never ends is already caught without a clock: run directly, the test's timeout kills it and fails on the exit status; run through the menu, the menu's own timeout stops it before the Terminal bind the test looks for. The outer timeouts are now long enough that only a hang reaches them. The approach is omacom#15704's.

Co-Authored-By: Dylan Kenney <dylan@dkenney.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@omarchybot omarchybot added ready Good to merge and removed ready Good to merge labels Oct 18, 2026
@omarchybot

Copy link
Copy Markdown
Collaborator

I pushed one commit to this branch (now 3deb771). It brings in the test approach from #15704, with @dmkenney credited as co-author: the scan tests no longer use a wall-clock budget.

Before this, menu-keybindings-lua-scan-test.sh failed any scan that took 5 s or more, which a slow or busy machine can hit with nothing wrong. A scan that never ends is still caught, by its exit status instead of the clock. Run directly, a hung scan is killed by the test's timeout 60 and the test fails. Run through the menu, the menu's own timeout 10 stops it before the Terminal bind that keybindings-menu-test.sh looks for. The outer timeouts went from 10 s to 60 s, so only a real hang reaches them. The code in bin/ did not change.

What ran, on a worker built from the Omarchy ISO, against current quattro (4e059ac):

  • With this pull request's tests, the scan test and the window-walk test both fail on quattro, and both fail with the bin/ change reverted.
  • With only the instruction-count hook removed, the runaway test still fails. It waits out the 60 s timeout and fails on the exit status, so the hook stays covered without a clock.
  • On this head, menu-keybindings-lua-scan-test.sh passes 4/4, keybindings-menu-test.sh 21/21 and menu-keybindings-key-names-test.sh 3/3, and ./test/cli passes. All of that holds again with the head merged with current quattro, which merges cleanly.

Codex Medium reviewed the new commit and found no defects. It confirmed that both regression cases still fail when what they guard is broken. It noted that the only thing the old checks proved and these do not is finishing within 5 s on the test machine, which is the budget this change removes on purpose. It can read this session's notes, so where it agrees with me, that agreement isn't guaranteed to be independent.

This is verified at the new head. It is not marked ready, because #12712 carries ready for the same bug, and only one of the two should land. On current quattro, #12712 still stops the scan at qconsole.lua:123, and this pull request does not. Choosing between them is the maintainer's call, and that is what this waits on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working verified Omarchy Triage has verified that this issue is ready for final review

Projects

None yet

9 participants